<?php

/**********************************************************************
  TemplarPHP 9.10r1 - The cascading template framework for PHP.
  Copyright (C) 2007, 2008, 2009  Shawn Brown <http://shawnbrown.com/>

  This program is free software: you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation, either version 3 of the License, or
  (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program.  If not, see <http://www.gnu.org/licenses/>.

 *********************************************************************/


/**********************************************************************
 * Manages client-server and Cascade/Template-server interactions.
 *********************************************************************/
class TemplarPHP {
    protected $cascade  = NULL; // gets TemplarCascadeFramework instance
    //protected $template = NULL; // gets TemplarTemplateEngine instance

    public $envRemoteAddr = NULL; // ip address of client request
    public $envUserAgent  = NULL; // client browser's user agent
    public $envScheme     = NULL; // http or https
    public $envHost       = NULL; // server's host name
    public $envScript     = NULL; // controller script file name
    public $envWebRoot    = NULL; // root of public web directory
    public $envFileRoot   = NULL; // root file path on server

    public $defaultIndex = 'index'; // "trailing slash" redirect
    public $logLevel     = 2;       // 0 is 'off', higher # is more detail
    public $eventLog     = array(); //

    /* response variables */
    protected $responseStatus  = NULL;    // response status value
    protected $responseHeaders = array(); // array of response headers
    public $dom = NULL;                   // compiled DOMDocument

    /*  */
    public function __construct() {
        /* instantiate helper classes */
        //$this->cascade  = new TemplarCascadeFramework($this);
        //$this->template = new TemplarTemplateEngine($this);

        /* set environment variables */
        $this->envRemoteAddr = $_SERVER['REMOTE_ADDR'];
        $this->envUserAgent  = $_SERVER['HTTP_USER_AGENT'];
        $this->envScheme = ($_SERVER['HTTPS'] == 'on') ? 'https' : 'http';
        $this->envHost   = $_SERVER['HTTP_HOST'];
        $this->envScript = $_SERVER['SCRIPT_NAME'];
        $env_root = dirname($this->envScript);
        $env_root = rtrim($env_root, '/') . '/'; // must end with single slash?
        $this->envWebRoot = $env_root;
        $env_root = realpath(basename($this->envScript));
        $env_root = pathinfo($env_root, PATHINFO_DIRNAME);
        $this->envFileRoot = $env_root . DIRECTORY_SEPARATOR;
    }
    /* loads a new request */
    public function loadRequest($request) { // TODO: $markup=NULL?
        $request = $this->formatRequest($request);
        $cascade = new TemplarCascadeFramework(&$this);
        $cascade->loadContent($request); // errors may disallow caching
        $this->cascade = $cascade;
        $this->dom = NULL; // clear dom
    }
    /*  */
    public function buildResponse() {
        if (!$this->cascade)
            throw new Exception(($this->dom) ? 'Response already exists.' : 'No request loaded.');
        $cascade =& $this->cascade;
        if (/* $this->cachingAllowed && */ $cascade->cachingAllowed) {
            $resources = array_merge(array($cascade->contentPath), $cascade->templatePaths);
            $last_modified = $this->getLastModified($resources);
            $modified_since = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);

            $etag_hash = $this->controllerScriptHash();
            $if_none_match = preg_split('/[ ]*,[ ]*/', $_SERVER['HTTP_IF_NONE_MATCH']);

            if ($modified_since==$last_modified && in_array($etag_hash,$if_none_match)) {
                $this->registerStatusCode('304 Not Modified');
                return; // <- EXIT!
            }
            $cascade->applyTemplates();   // errors may disallow caching
            //$cascade->processHttpEquiv(); // directives may disallow caching
            if ($cascade->cachingAllowed) {
                $http_date = gmdate('D, d M Y H:i:s', $last_modified) . ' GMT';
                $this->registerHeader('Last-Modified', $http_date);
                $this->registerHeader('ETag', $etag_hash);
            }
        } else {
            $cascade->applyTemplates();
            //$cascade->processHttpEquiv();
        }
        //$this->mimeTypes = $cascade->mimeTypes;?
        $this->dom =& $cascade->dom;
        //$this->cascade = NULL;
    }
    /*  */
    public function sendResponse() {
        //$this->sendHeaders();
        echo $this->saveMarkup();
    }
    /*  */
    //public function sendHeaders() {
    //}
    /*  */
    public function saveMarkup() {
        // set content header and meta-tag according to mimetype
        // determine save method saveXML or saveHTML
        return $this->dom->saveXML();
    }
    /* gets unique hash for included php files (for ETag cache control) */
    protected function controllerScriptHash() {
        $paths = get_included_files();
        $mtimes = array_map('filemtime', $paths);
        $string = implode('~',$paths) . ':' . implode('~',$mtimes);
        return md5($string);
    }
    /* accepts array of paths returns most recent last-modified timestamp */
    protected function getLastModified($path_arr) { // relative paths
        $root = $this->envFileRoot;
        $callback = create_function('$x', 'return filemtime("'.$root.'$x");');
        return max(array_map($callback, $path_arr));
    }
    /* get request uri (relative to script's root directory) */
    protected function formatRequest($request) {
        $request = preg_replace('/[?&#].*$/', '', $request); // remove query & frag
        if (strpos($request,$this->envWebRoot) === 0) { // if starts with web root
            $pos = strlen($this->envWebRoot);
            $request = substr($request, $pos); // trim web root
        } else {
            $request = ltrim($request, '/'); // treat as relative
        }
        if (substr($request,-1)=='/' || $request=='')
            $request .= $this->defaultIndex; // if directory, append index name
        return $request;
    }

    /* pushes $message onto eventLog array */
    public function log($level, $message) {
        if ($level <= $this->logLevel)
            $this->eventLog[] = $message;
    }
    /* accepts status-code & reason-phrase (don't include protocol) */
    public function registerStatusCode($code_and_phrase) {
        $this->responseStatus = $code_and_phrase;
        $this->log(2, "HEADER: $code_and_phrase");
    }
    /* accepts name and value, logs header response */
    public function registerHeader($field_name, $field_value) {
        if (array_key_exists($field_name,$this->responseHeaders))
            $this->log(2, "ALERT: Overwriting '$field_name' header.");
        $this->responseHeaders[$field_name] = $field_value;
        $this->log(2, "HEADER: $field_name: $field_value");
    }
}



/**********************************************************************
 *
 *********************************************************************/
class TemplarCascadeFramework {
    /* populated at instantiation */
    protected $templar              = NULL;    // TemplarPHP reference
    protected $registeredTypes      = array(); // known file types
    protected $registeredExtensions = array(); // known file extensions
    protected $entityToDecimalMap   = NULL;    // used to load XML quickly

    /* populated by loadContent() */
    public $request       = NULL;    // copy of current request
    public $contentPath   = NULL;    // path to content file
    public $templatePaths = array(); // array of template paths
    public $dom           = NULL;    // DOMDocument object

    /* default template config */
    public $fixedTemplates     = array();
    public $cascadingTemplates = array('template');

    /* misc cascade settings */
    public $commandPrefix      = 'templar:'; // meta tag command prefix
    public $cachingAllowed     = TRUE;       // cache behavior switch
    public $zIndexLevels       = 3;          // number of 'z-index' stack levels

    /*  */
    public function __construct(&$templar) {
        $this->templar =& $templar;

        /* initialize built-in file type & extension defintions */
        $this->registeredTypes      = $this->getBuiltInTypes();
        $this->registeredExtensions = $this->getBuiltInExtensions();

        /* prep entity encoding map - to load xml without external entities */
        $this->entityToDecimalMap = self::buildEntityToDecimalMap();
    }
    /******************************************************************
     *
     *****************************************************************/
    /* return a recognized file type - used by getMarkup() */
    protected function getFileType($file_path) {
        $extension = pathinfo($file_path, PATHINFO_EXTENSION);
        if (array_key_exists($extension, $this->registeredExtensions)) {
            $type_key = $this->registeredExtensions[$extension];
            $file_type = $this->registeredTypes[$type_key];
        } else {
            $file_type = array('name'=>NULL, 'handler'=>NULL, 'size_limit'=>NULL);
        }
        return $file_type;
    }
    /* set starting format & values for registered file types */
    protected function getBuiltInTypes() {
        return array(
            'code' => array(
                'name'       => 'code',
                'handler'    => array($this, '_getMarkupFromCode'),
                'max_kb'     => 2048 // size in kb (2mb default)
            ),
            'markup' => array(
                'name'       => 'markup',
                'handler'    => 'file_get_contents',
                'max_kb'     => 2048 // size in kb (2mb default)
            ),
            'text' => array(
                'name'       => 'text',
                'handler'    => array($this, '_getMarkupFromText'),
                'max_kb'     => 2048 // size in kb (2mb default)
            )
        );
    }
    /* set starting format & values for registered file extensions */
    protected function getBuiltInExtensions() {
        return array(
            'php'   => 'code',
            'php5'  => 'code',
            'html'  => 'markup',
            'htm'   => 'markup',
            'xhtml' => 'markup',
            'xht'   => 'markup',
            'txt'   => 'text'
        ); //order determines extension precedence
    }
    /******************************************************************
     *
     *****************************************************************/
    /* prepare to cascade, set cachingAllowed, set headers? */
    public function loadContent($request) {
        $path = $this->getPath($request);
        /*
        if ($content_path==NULL && basename($request)==$this->templar->defaultIndex) {
            // test for accidental trailing slash, if TRUE set redirect
            // $this->cachingAllowed=FALSE, and return early
        }
        */
        // check for path restriction
        // if restricted, set error dom, cachingAllowed=FALSE, return early
        $markup = $this->getMarkup($path); // sets status code if error
        //if (status code is set)
        //    $this->cacheingAllowed = FALSE;
        $dom = $this->getDom($markup, $path); // pass request instead of path?
        // check for doctype restriction
        // if restricted, set error dom, cachingAllowed=FALSE

        $commands = $this->getCommandNodes($dom);
        //$this->evalCommands($commands);
        //$this->removeCommandNodes($commands);

        $this->request = $request;
        $this->contentPath = $path;
        $this->templatePaths = $this->getAllTemplatePaths($request);

        //$this->resolveUrls(&$dom);
        $this->dom = $dom;
    }
    /*  */
    //public getResourcePaths() {
    //    return array_merge(array($this->contentPath), $this->templatePaths);
    //}
    /* get template paths from directory cascade */
    public function getTemplatePaths() {
        return ($this->cascade) ? $this->cascade->templatePaths : NULL;
    }
    /* set template paths */
    public function setTemplatePaths($paths) {
        //filter for existing files only
        if ($paths && $this->cascade)
            $this->cascade->templatePaths = $paths;
        //else
        //    throw error
    }
    /*  */
    public function applyTemplates() {
        //if ($this->templar->customTemplateEngine) {
        //    $class_name = $this->templar->customTemplateEngine;
        //    $template_engine = new $class_name();
        //} else
            $template_engine = new TemplarTemplateEngine(&$this);
        //}
        foreach ($this->templatePaths as $path) {
            $template_engine->assignValues(&$this->dom);
            $markup = $this->getMarkup($path);
            $this->dom =& $template_engine->renderDom($markup);
            //$this->resolveUrls(&$this->dom, $path);
        }
        // make templar links
    }
    /*  */
    protected function getAllTemplatePaths($request) {
        $this->templar->log(2, "CASCADE: z-index set to {$this->zIndexLevels}.");
        $dir = dirname($request) . DIRECTORY_SEPARATOR;
        $paths = array();
        /* get fixed templates */
        foreach ($this->fixedTemplates as $name) {
            $path = $this->getTemplatePath($dir, $name);
            if ($path)
                $paths[] = $path;
        }
        /* if fixed templates, set $dir to most senior directory */
        if ($paths) {
            $paths = self::sortTemplatePaths($paths);
            $most_senior = end($paths);
            $dir = dirname($most_senior) . DIRECTORY_SEPARATOR;
        }
        /* get cascading templates */
        while ($dir !== NULL) {
            foreach ($this->cascadingTemplates as $name) {
                $path = $this->getTemplatePath($dir, $name);
                if ($path)
                    $paths[] = $path;
            }
            $dir = self::getParentDirName($dir);
        }
        $paths = array_unique($paths); // remove any duplicates
        $paths = self::sortTemplatePaths($paths);
        return $paths;
    }
    /* sort template paths */
    protected static function sortTemplatePaths($paths) {
        $order = ($paths) ? range(1, count($paths)) : array();

        $z_index = array_map('basename', $paths); // get basename only
        $fn = create_function('$x', 'return (preg_match("/^_z+_/", $x)) ? $x : "";');
        $z_index = array_map($fn, $z_index); // clear non-z-index strings
        $fn = create_function('$x', 'return preg_replace("/^_(z+).*/","\$1",$x);');
        $z_index = array_map($fn, $z_index); // get 'z' chars only
        $fn = create_function('$x', 'return strlen($x);');
        $z_index = array_map($fn, $z_index); // get number of 'z' chars

        $fn = create_function('$x', 'return substr_count($x,DIRECTORY_SEPARATOR);');
        $folder_depth = array_map($fn, $paths);

        array_multisort($folder_depth, SORT_DESC, $z_index, SORT_DESC, $order, $paths);
        return $paths;
    }
    /*  */
    protected function getTemplatePath($dir, $name) {
        $mod_dir = dirname($name);
        if ($mod_dir) { // if name includes modified or relative dir
            $dir = self::resolvePathSymbols($dir . $mod_dir . '/');
            $name = basename($name);
        }
        $path = $this->getPath($dir . '_' . $name);
        $index = 1;
        while ($path==NULL && $index <= $this->zIndexLevels) {
            $prefix = '_' . str_repeat('z',$index) . '_';
            $path = $this->getPath($dir . $prefix . $name);
            $index++;
        }
        return $path;
    }
    /*  */
    protected function getCommandNodes($dom) {
        $prefix = $this->commandPrefix;
        $commands = array();
        foreach ($dom->getElementsByTagName('meta') as $tag) {
            $name = $tag->getAttribute('name');
            if (strpos($name,$prefix) === 0)
                $commands[] = $tag;
        }
        return $commands;
    }
    /* return DOM representation of given markup */
    public function getDom($markup, $path=NULL) {
        // TODO: $dom->documentURI = getUri($path);
        $dom = new DOMDocument();
        $markup = strtr($markup, $this->entityToDecimalMap); // instead of resolveExternals
        set_error_handler(create_function('$code,$message', 'throw new Exception($message);'), E_WARNING);
        try {
            $dom->loadXML($markup); // try as XML (strict parsing)
        } catch (Exception $e) {
            try {
                $dom->loadHTML($markup); // try as HTML
                $doctype_info = self::getDoctypeInfo($dom->doctype);
                if ($this->strictParsing && reset($doctype_info['mime'])!=='text/html')
                    throw new Exception();
                if (in_array('application/xhtml+xml',$doctype_info['mime'])==TRUE
                        || in_array('text/html',$doctype_info['mime'])==FALSE) {
                    $dom = self::changeDoctype($dom, NULL); // strip doctype
                    $this->templar->log(2, 'ERROR: '. $this->getDomLoadErrorMessage($e) . " - in file $path.");
                    $this->templar->log(2, "ERROR: The file - $path - parsed as HTML due to markup errors. Non-conforming DOCTYPE removed.");
                }
            } catch (Exception $e) {
                $allow_custom_error_doc = pathinfo($path,PATHINFO_FILENAME) != '500_bad_markup';
                //$markup = $this->getErrorMarkup('500_bad_markup', $path, '500 Internal Server Error', $markup, $allow_custom_error_doc);
                $error_text = ucwords($this->getDomLoadErrorMessage($e));
                $markup = $this->getErrorMarkup('500_bad_markup', $path, $error_text, $markup, $allow_custom_error_doc);
                $dom = $this->getDom($markup);
                $this->templar->log(2, "ERROR: The file - $path - can not be parsed - " . $this->getDomLoadErrorMessage($e));
                $this->templar->registerStatusCode('500 Internal Server Error');
                //$this->cachingAllowed = FALSE;
                // TODO: TEST with error in custom error doc
            }
        }
        restore_error_handler();
        return $dom;
    }
    /* return info as associative array for given doctype */
    protected static function getDoctypeInfo($public_id) {
        if (get_class($public_id) == 'DOMDocumentType')
            $public_id = $public_id->publicId;
        $info_arr = array(
            // TODO: sub-/super-set relationships below are loose, tighten this up
            '-//W3C//DTD HTML 4.01 Strict//EN'       => array(array(0),   8, 'html'),
            '-//W3C//DTD HTML 4.01 Transitional//EN' => array(array(0),  12, 'html'),
            '-//W3C//DTD HTML 4.01 Frameset//EN'     => array(array(0),  14, 'html'),
            '-//W3C//DTD HTML 4.01//EN'              => array(array(0),  15, 'html'),
            '-//W3C//DTD Compact HTML 1.0 Draft//EN' => array(array(6),   1, 'html'),
            '-//W3C//DTD XHTML Basic 1.0//EN'        => array(array(2,4), 1, 'xml'),
            '-//W3C//DTD XHTML Basic 1.1//EN'        => array(array(2,4), 2, 'xml'),
            '-//W3C//DTD XHTML-Print 1.0//EN'        => array(array(2,4), 3, 'print'),
            '-//WAPFORUM//DTD XHTML Mobile 1.0//EN'  => array(array(2,4), 4, 'mobile'),
            '-//WAPFORUM//DTD XHTML Mobile 1.1//EN'  => array(array(2,4), 5, 'mobile'),
            '-//WAPFORUM//DTD XHTML Mobile 1.2//EN'  => array(array(2,4), 6, 'mobile'),
            '-//W3C//DTD XHTML 1.0 Strict//EN'       => array(array(2,4), 7, 'xml_or_html'),
            '-//W3C//DTD XHTML 1.0 Transitional//EN' => array(array(2),  11, 'xml_or_html'),
            '-//W3C//DTD XHTML 1.0 Frameset//EN'     => array(array(2),  13, 'xml_or_html'),
            '-//W3C//DTD XHTML 1.1//EN'              => array(array(4),   9, 'xml'),
            '-//W3C//DTD XHTML+RDFa 1.0//EN'         => array(array(4),  10, 'xml')
            // TODO: return public_id & system_id (for XHTML 1.1 DOCTYPE mix-ins)
            //'-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN' => array(array(4),  12, $mime['xml'])
            //'-//W3C//DTD XHTML 1.1 plus MathML 2.0//EN'
            //'-//W3C//DTD XHTML 1.1 plus MathML 2.0 plus SVG 1.1//EN'
            //'-//W3C//DTD XHTML 2.0//EN'              => array(array(8), 11, $mime['xml'])
        );
        if (array_key_exists($public_id, $info_arr)) {
            list($family, $index, $mime_key) = $info_arr[$public_id];
            $values = array($family, $index, self::getMimeTypes($mime_key));
        } else {
            $values = array(array(), 100, self::getMimeTypes('html')); // tagsoup gets html mime
        }
        $keys = array('family', 'index', 'mime');
        return array_combine($keys, $values);
    }
    /* returns list of mime types in order of preference as recommended by standards */
    protected static function getMimeTypes($key) {
        $mime = array(
            'xml' => array(
                'application/xhtml+xml',
                'application/xml',
                'text/xml'
            ),
            'html' => array(
                'text/html'
            ),
            'xml_or_html' => array(
                'application/xhtml+xml',
                'application/xml',
                'text/xml',
                'text/html'
            ),
            'print' => array(
                'application/xhtml+xml',
                'application/vnd.pwg-xhtml-print+xml' // deprecated mime
            ),
            'mobile' => array(
                'application/vnd.wap.xhtml+xml',
                'application/xhtml+xml',
                'text/html'
            )
        );
        return (array_key_exists($key, $mime)) ? $mime[$key] : array();
    }

    /* takes getDom() exception, returns formatted error message */
    protected static function getDomLoadErrorMessage($exception, $encoded=FALSE) {
        $message = $exception->getMessage();
        $message = substr($message, strpos($message,']: ')+3); // trim method name
        return ($encoded) ? $message : html_entity_decode($message);
    }

    /*  */
    protected function getMarkup($file_path) {
        $this->templar->log(2, 'LOADING: ' . $file_path);
        $file_type = $this->getFileType($file_path);
        $absolute_file_path = $this->templar->envFileRoot . $file_path;
        switch (TRUE) {
            case $file_path === NULL:
                $this->templar->registerStatusCode('404 Not Found');
                $markup = $this->getErrorMarkup('404_not_found', $this->request);
                break;
            case in_array($absolute_file_path, get_included_files()):
                $this->templar->registerStatusCode('403 Forbidden');
                $markup = $this->getErrorMarkup('403_self_load', $file_path, '403 Forbidden');
                break;
            /*
            case $this->pregInArray($file_path, $this->accessUnavailable)
                    && $this->privilegedAccessFn() == FALSE:
                $this->registerStatusCode('503 Service Unavailable');
                $markup = $this->getErrorMarkup('503_unavailable', $file_path, 'Down For Maintenance');
                break;
            case $file_type['name'] == 'code'
                    && $this->pregInArray($file_path, $this->accessNoPhp)
                    && $this->privilegedAccessFn() == FALSE:
                $this->registerStatusCode('500 Internal Server Error');
                $markup = $this->getErrorMarkup('500_no_php', $file_path, '500 Internal Server Error');
                break;
            case $file_type['max_kb']
                    && (filesize($absolute_file_path)/1024) > $file_type['max_kb']:
                    // filesize() may fail on some servers if file > 2gb
                $this->registerStatusCode('413 Request Entity Too Large');
                $markup = $this->getErrorMarkup('413_too_large', $file_path, '413 Request Entity Too Large');
                break;
            */
            default:
                $markup = call_user_func($file_type['handler'], $absolute_file_path);
        }
        return $markup;
    }
    /* return markup from .php (vars prefixed w/ 'TEMPLAR_' to prevent collisions) */
    protected function _getMarkupFromCode($TEMPLAR_absolute_file_path) {
        $TEMPLAR_original_cwd = getcwd();
        chdir(pathinfo($TEMPLAR_absolute_file_path, PATHINFO_DIRNAME)); // change cwd

        //$TEMPLAR_engine_label = $this->engineLabel;
        //$$TEMPLAR_engine_label = $this->engineObject; // yes, a 'variable variable'
        ob_start();
        include($TEMPLAR_absolute_file_path);
        $content = ob_get_contents();
        ob_end_clean();

        chdir($TEMPLAR_original_cwd); // restore previous working directory
        return $content;
    }
    /*  */
    protected function _getMarkupFromText($absolute_path) {
        $content = file_get_contents($absolute_path);
        $content = self::formatText($content);
        $title = self::pathToTitle($absolute_path);
        return self::wrapInHtml($content, $title);
    }
    /******************************************************************
     *
     *****************************************************************/
    /*  */
    protected function getErrorMarkup($error, $path, $title=NULL, $info=NULL, $allow_custom_doc=TRUE) {
        $this->cachingAllowed = FALSE; // disallow caching
        $callback = array($this, '_error_'.$error);
        if (is_callable($callback) == FALSE)
            throw new Exception('Method "'.$callback[1].'()" is undefined - triggered by "'.$error.'" parameter.');
        $dir = dirname($path) . DIRECTORY_SEPARATOR;
        $custom_error_doc = $this->getBubbledPath($dir . $error);

        if ($custom_error_doc && $allow_custom_doc) {
            $markup = $this->getMarkup($custom_error_doc);
        } else {
            if ($title == NULL)
                $title = self::pathToTitle($error);
            $markup = call_user_func($callback, $path, $info);
            $markup = self::formatText($markup, FALSE);
            $markup = self::wrapInHtml($markup, $title);
        }
        return $markup;
    }
    /* Requested file not found (use with '404 Not Found') */
    protected function _error_404_not_found($request) {
        $scheme   = $this->templar->envScheme;
        $host     = $this->templar->envHost;
        $web_root = $this->templar->envWebRoot;
        $location = $scheme .'://'. $host . $web_root . $request;
        $location = htmlentities($location);
        return "The web page - <strong>$location</strong> - does not exist.";
    }
    /* forbidden (use with '403 Forbidden') */
    protected function _error_403_self_load($path) {
        return 'Don\'t cross the streams. It would be bad.';
    }
    /* Resource contains unparsable errors (send with 500 Internal Server Error) */
    protected function _error_500_bad_markup($path=NULL, $bad_markup=NULL) {
        $host = $this->templar->envHost;
        if ($host=='localhost' || $host=='127.0.0.1' || $path===NULL) {
            $w3c_href = 'http://validator.w3.org/#validate_by_input';
        } else {
            $uri = $this->templar->envScheme . '://' . $this->templar->envHost . $this->templar->envWebRoot . $path;
            $w3c_href = 'http://validator.w3.org/check?uri=' . urlencode($uri);
        }
        $bad_markup = htmlentities($bad_markup);
        $convmap = array(0x80, 0x2FFFF, 0, 0xFFFF); // chars outside ascii range
        $bad_markup = mb_encode_numericentity($bad_markup, $convmap, 'UTF-8'); // for encoding errors
        if (!$path)
            $path = 'unknown file';
        return "The file - <strong>$path</strong> - contains unparsable
            errors. <a href='$w3c_href'>Check For Errors</a> using the
            W3C's free Markup Validator.
            <textarea rows=\"15\" cols=\"80\">$bad_markup</textarea>
            <br />
            If you are having difficulty correcting the errors in your
            document, you may want to try fixing the invalid markup
            using the <a href='http://cgi.w3.org/cgi-bin/tidy'>HTML
            Tidy Online</a> service."; // TODO: fix markup (whole block wrapped in tt)
    }
    /******************************************************************
     *
     *****************************************************************/
    /* returns a full html entity-to-decimal mapping appropriate for strtr(),
       used to quick-load xml instead of using resolveExternals */
    protected static function buildEntityToDecimalMap() {
        $extended = array(
            '&OElig;'   =>'&#338;',  '&oelig;'  =>'&#339;',  '&Scaron;'=>'&#352;',
            '&scaron;'  =>'&#353;',  '&Yuml;'   =>'&#376;',  '&fnof;'  =>'&#402;',
            '&circ;'    =>'&#710;',  '&tilde;'  =>'&#732;',  '&Alpha;' =>'&#913;',
            '&Beta;'    =>'&#914;',  '&Gamma;'  =>'&#915;',  '&Delta;' =>'&#916;',
            '&Epsilon;' =>'&#917;',  '&Zeta;'   =>'&#918;',  '&Eta;'   =>'&#919;',
            '&Theta;'   =>'&#920;',  '&Iota;'   =>'&#921;',  '&Kappa;' =>'&#922;',
            '&Lambda;'  =>'&#923;',  '&Mu;'     =>'&#924;',  '&Nu;'    =>'&#925;',
            '&Xi;'      =>'&#926;',  '&Omicron;'=>'&#927;',  '&Pi;'    =>'&#928;',
            '&Rho;'     =>'&#929;',  '&Sigma;'  =>'&#931;',  '&Tau;'   =>'&#932;',
            '&Upsilon;' =>'&#933;',  '&Phi;'    =>'&#934;',  '&Chi;'   =>'&#935;',
            '&Psi;'     =>'&#936;',  '&Omega;'  =>'&#937;',  '&alpha;' =>'&#945;',
            '&beta;'    =>'&#946;',  '&gamma;'  =>'&#947;',  '&delta;' =>'&#948;',
            '&epsilon; '=>'&#949;',  '&zeta;'   =>'&#950;',  '&eta;'   =>'&#951;',
            '&theta;'   =>'&#952;',  '&iota;'   =>'&#953;',  '&kappa;' =>'&#954;',
            '&lambda;'  =>'&#955;',  '&mu;'     =>'&#956;',  '&nu;'    =>'&#957;',
            '&xi;'      =>'&#958;',  '&omicron;'=>'&#959;',  '&pi;'    =>'&#960;',
            '&rho;'     =>'&#961;',  '&sigmaf;' =>'&#962;',  '&sigma;' =>'&#963;',
            '&tau;'     =>'&#964;',  '&upsilon;'=>'&#965;',  '&phi;'   =>'&#966;',
            '&chi;'     =>'&#967;',  '&psi;'    =>'&#968;',  '&omega;' =>'&#969;',
            '&thetasym;'=>'&#977;',  '&upsih;'  =>'&#978;',  '&piv;'   =>'&#982;',
            '&ensp;'    =>'&#8194;', '&emsp;'   =>'&#8195;', '&thinsp;'=>'&#8201;',
            '&zwnj;'    =>'&#8204;', '&zwj;'    =>'&#8205;', '&lrm;'   =>'&#8206;',
            '&rlm;'     =>'&#8207;', '&ndash;'  =>'&#8211;', '&mdash;' =>'&#8212;',
            '&lsquo;'   =>'&#8216;', '&rsquo;'  =>'&#8217;', '&sbquo;' =>'&#8218;',
            '&ldquo;'   =>'&#8220;', '&rdquo;'  =>'&#8221;', '&bdquo;' =>'&#8222;',
            '&dagger;'  =>'&#8224;', '&Dagger;' =>'&#8225;', '&bull;'  =>'&#8226;',
            '&hellip;'  =>'&#8230;', '&permil;' =>'&#8240;', '&prime;' =>'&#8242;',
            '&Prime;'   =>'&#8243;', '&lsaquo;' =>'&#8249;', '&rsaquo;'=>'&#8250;',
            '&oline;'   =>'&#8254;', '&frasl;'  =>'&#8260;', '&euro;'  =>'&#8364;',
            '&image;'   =>'&#8465;', '&weierp;' =>'&#8472;', '&real;'  =>'&#8476;',
            '&trade;'   =>'&#8482;', '&alefsym;'=>'&#8501;', '&larr;'  =>'&#8592;',
            '&uarr;'    =>'&#8593;', '&rarr;'   =>'&#8594;', '&darr;'  =>'&#8595;',
            '&harr;'    =>'&#8596;', '&crarr;'  =>'&#8629;', '&lArr;'  =>'&#8656;',
            '&uArr;'    =>'&#8657;', '&rArr;'   =>'&#8658;', '&dArr;'  =>'&#8659;',
            '&hArr;'    =>'&#8660;', '&forall;' =>'&#8704;', '&part;'  =>'&#8706;',
            '&exist;'   =>'&#8707;', '&empty;'  =>'&#8709;', '&nabla;' =>'&#8711;',
            '&isin;'    =>'&#8712;', '&notin;'  =>'&#8713;', '&ni;'    =>'&#8715;',
            '&prod;'    =>'&#8719;', '&sum;'    =>'&#8721;', '&minus;' =>'&#8722;',
            '&lowast;'  =>'&#8727;', '&radic;'  =>'&#8730;', '&prop;'  =>'&#8733;',
            '&infin;'   =>'&#8734;', '&ang;'    =>'&#8736;', '&and;'   =>'&#8743;',
            '&or;'      =>'&#8744;', '&cap;'    =>'&#8745;', '&cup;'   =>'&#8746;',
            '&int;'     =>'&#8747;', '&there4;' =>'&#8756;', '&sim;'   =>'&#8764;',
            '&cong;'    =>'&#8773;', '&asymp;'  =>'&#8776;', '&ne;'    =>'&#8800;',
            '&equiv;'   =>'&#8801;', '&le;'     =>'&#8804;', '&ge;'    =>'&#8805;',
            '&sub;'     =>'&#8834;', '&sup;'    =>'&#8835;', '&nsub;'  =>'&#8836;',
            '&sube;'    =>'&#8838;', '&supe;'   =>'&#8839;', '&oplus;' =>'&#8853;',
            '&otimes;'  =>'&#8855;', '&perp;'   =>'&#8869;', '&sdot;'  =>'&#8901;',
            '&lceil;'   =>'&#8968;', '&rceil;'  =>'&#8969;', '&lfloor;'=>'&#8970;',
            '&rfloor;'  =>'&#8971;', '&lang;'   =>'&#9001;', '&rang;'  =>'&#9002;',
            '&loz;'     =>'&#9674;', '&spades;' =>'&#9824;', '&clubs;' =>'&#9827;',
            '&hearts;'  =>'&#9829;', '&diams;'  =>'&#9830;'
        );
        $basic = get_html_translation_table(HTML_ENTITIES, ENT_QUOTES); // &#68; thru &#255;???
        $basic = array_flip($basic);
        $basic = array_map(create_function('$x', 'return \'&#\'.ord($x).\';\';'), $basic);
        return array_merge($basic, $extended);
    }
    /* find & return path for named file in current or closest ancestor folder */
    protected function getBubbledPath($request) {
        $path = $this->getPath($request);
        while ($path===NULL && $request) {
            $request = self::bubbleOnce($request);
            $path = $this->getPath($request);
        }
        return $path;
    }
    /* build path (or request) for file of same name in parent directory */
    protected static function bubbleOnce($path) {
        $parent_dir = self::getParentDirName($path);
        if ($parent_dir !== NULL) {
            $file_name = basename($path);
            $bubbled_path = $parent_dir . $file_name;
        } else {
            $bubbled_path = NULL;
        }
        return $bubbled_path;
    }
    /* return parent directory of given path or request */
    protected static function getParentDirName($path) {
        if ($path == '/')
            return NULL; // TODO: FIX THIS
        $path = self::resolvePathSymbols($path);
        $pos = strrpos($path, '/');
        if ($pos === 0)
            return NULL; // <- EXIT!
        if ($pos !== FALSE) {
            $path = substr($path, 0, $pos);
            $pos = strrpos($path, '/');
            $parent = ($pos !== FALSE) ? substr($path,0,$pos+1) : '';
        } else {
            $parent = NULL; // else current dir is root, no parent possible
        }
        return $parent;
    }
    /* resolve path's symbolic components (./ and ../) */
    protected static function resolvePathSymbols($path, $delim=DIRECTORY_SEPARATOR) {
        $leading = ($path{0} == $delim) ? $delim : ''; // contains leading slash
        $path = ltrim($path, $delim);

        $path = explode($delim, $path);
        $real_path = array();
        foreach ($path as $chunk) {
            if ($chunk == '.')
                continue;
            if ($chunk == '..') {
                $discarded = array_pop($real_path);
                if ($discarded === NULL) // if null, path is malformed TODO: look to leave unresolved?
                    return NULL;         // <- EXIT!
            } else {
                array_push($real_path, $chunk);
            }
        }
        if (count($real_path)>1 && end($real_path)=='')
            $trailing = $delim;
        else
            $trailing = '';
        $real_path = array_filter($real_path); // remove empty elements
        $real_path = $leading . implode($delim,$real_path) . $trailing;

        /* ugly corner cases */
        if ($real_path == $delim.$delim)
            $real_path = $delim;
        if ($real_path==$delim && $leading!=$delim)
            $real_path = '';

        return $real_path;
    }
    /* return content wrapped in valid XHTML Basic markup */
    protected static function wrapInHtml($content, $title='', $heading=TRUE) {
        return '<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.0//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic10.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
    <head>
        <title>' . $title . '</title>
    </head>
    <body>
        ' . ( ($heading && $title) ? '<h1>'.$title.'</h1>' : '' ) . '
        ' . $content . '
    </body>
</html>';
    }
    /* return file name with words capitalized & underscores replaced with spaces */
    protected static function pathToTitle($file_path) {
        $title = pathinfo($file_path, PATHINFO_FILENAME);
        $title = str_replace('_', ' ', $title);
        return ucwords($title);
    }
    /* return escaped text wrapped in pre tags */
    protected static function formatText($text, $escape=TRUE, $preformatted=FALSE) {
        $tag = ($preformatted) ? 'pre' : 'tt';
        return "<$tag>" . (($escape) ? htmlentities($text) : $text) . "</$tag>";
    }
    /* return path of requested file (checks known extensions) */
    protected function getPath($file_request) {
        $root_path = $this->templar->envFileRoot;
        $file_request = rawurldecode($file_request);
        if (pathinfo($file_request, PATHINFO_EXTENSION))
            return (file_exists($root_path.$file_request)) ? $file_request : NULL; // <- EXIT!
        $extensions = array_keys($this->registeredExtensions);
        foreach ($extensions as $extension) {
            $check_file = $file_request . '.' . $extension;
            if (file_exists($root_path . $check_file))
                return $check_file; // <- EXIT!
        }
        return NULL;
    }


}


/**********************************************************************
 *
 *********************************************************************/
class TemplarTemplateEngine {
    protected $cascade          = NULL; // TemplarCascadeFramework ref
    protected $sandboxLabel    = NULL; // variable name for in-template code
    protected $manglingToken    = NULL;
    protected $processingTarget = NULL;

    protected $contentDom    = NULL; //
    protected $builtInVars = array('title', 'body'); // valid labels, lower case
    public $varNodes = array();
    protected $debugMode = TRUE;
    //protected $templateFunctions = array();
    //protected $renderedNodes = array();

    /*  */
    public function __construct(&$cascade) {
        $this->cascade =& $cascade;
        $this->sandboxLabel = 'content';
        $this->manglingToken = '_' . base_convert(mt_rand(), 10, 36);
        $this->processingTarget = 'templar' . $this->manglingToken;
    }
    /******************************************************************
     *
     *****************************************************************/
    /* populate template values with content dom nodes */
    public function assignValues(&$content_dom) {
        foreach (self::getAllIdElements($content_dom) as $key=>$element) { // get id'd nodes
            $element->setAttribute('id', $this->mangle($key));
            $this->varNodes[$key] = $element;
        }
        foreach ($this->builtInVars as $built_in) { // get built-in var nodes
            $element = self::nodeByTagName($content_dom, $built_in);
            if ($element)
                $this->varNodes[$built_in] = $element;
        }
        $this->contentDom =& $content_dom;
    }
    /* return associative array of elements with id attributes */
    protected static function getAllIdElements(&$dom) {
        $arr = array();
        $node = $dom->documentElement;
        while ($node = self::followingNode($node)) {
            if ($node->nodeType==XML_ELEMENT_NODE && $node->hasAttribute('id'))
                $arr[$node->getAttribute('id')] = $node;
        }
        return $arr; // TODO: benchmark against '*' and xpath
    }
    /* return node that follows given node */
    protected static function followingNode($node) {
        if ($node->firstChild)
            return $node->firstChild;       // <- EXIT!
        if ($node->nextSibling)
            return $node->nextSibling;      // <- EXIT!
        while ($node = $node->parentNode) {
            if ($node->nextSibling)
                return $node->nextSibling;  // <- EXIT!
        }
        return NULL;
    }
    /******************************************************************
     *
     *****************************************************************/
    /* return mangled value of given string */
    protected function mangle($val) {
        return (!$this->isMangled($val)) ? ($val . $this->manglingToken) : $val;
    }
    /* return unmangled value of given string */
    protected function unmangle($val) {
        if ($this->isMangled($val)) {
            $length = strlen($val) - strlen($this->manglingToken);
            return substr($val, 0, $length); // <- EXIT!
        }
        return $val;
    }
    /* tests if given string is mangled */
    protected function isMangled($val) {
        $token = $this->manglingToken;
        return substr($val,-strlen($token)) == $token;
    }
    /******************************************************************
     *
     *****************************************************************/
    /* Accepts template markup, returns rendered DOM document. */
    public function renderDom($markup) {
        /*
        $markup = explode("\n", $markup);
        $pattern = '/{{(.*)}}/'; // template statement pattern
        $callback = array($this, 'processExpression');
        for ($i=0,$count=count($markup); $i<$count; $i++) {
            $markup[$i] = preg_replace_callback($pattern, $callback, $markup[$i]);
            // populates renderedNodes, returns processing instructions
        }
        $markup = implode("\n", $markup);
        */
        $markup = $this->processAllExpressions($markup);
        // populates renderedNodes, replaces tags with processing instructions

        /* detatch nodes from tree */
        foreach ($this->renderedNodes as $node) {
            self::removeNode($node); // resolves nested elements
        }
        /* replace processing instructions with nodes */
        $template_dom = $this->cascade->getDom($markup);
        foreach ($this->getProcessingInstructions($template_dom) as $node) {
            $name = trim($node->data);
            $children = $this->renderedNodes[$name]->childNodes;
            foreach ($children as $child) {
                $imported = $template_dom->importNode($child, TRUE);
                $node->parentNode->insertBefore($imported, $node);
            }
            self::removeNode($node); // remove processing instruction
        }
        /* unmangle id value or remove if collision */
        $elements = self::getAllIdElements($template_dom);
        foreach ($elements as $key=>$element) {
            if ($this->isMangled($key)) {
                $unmangled = $this->unmangle($key);
                if (!array_key_exists($unmangled,$elements)) {
                    $element->setAttribute('id', $unmangled);
                } else {
                    self::removeNode($element);
                    // TODO: add 'ID conflict!' to log
                }
            }
        }

        //$template_markup = '';
        //$target_dom = $this->cascade->getDom($template_markup);
        return $template_dom;
        return $this->contentDom;
    }
    /*  */
    protected function processAllExpressions($markup) {
        $markup = explode("\n", $markup);
        $pattern = '/{{(.*)}}/'; // template statement pattern
        $callback = array($this, 'processExpression');
        for ($i=0,$count=count($markup); $i<$count; $i++) {
            $markup[$i] = preg_replace_callback($pattern, $callback, $markup[$i]);
            // populates renderedNodes, returns processing instructions
        }
        return implode("\n", $markup);
    }
    /* eval expression, log rendered nodes, return processing instruction */
    protected function processExpression($matches) {
        $expr = $matches[1];
        $result = $this->evalExpression($expr); // result is dom node
        $label = $this->getVariableName($result);
        $this->renderedNodes[$label] = $result;
        return '<?' . $this->processingTarget . ' ' . $label . ' ?>';
    }
    /*  */
    protected function __get($prop) {
//echo "eeeeeeeeeeeerp!!!!!!!!";
        switch (strtolower($prop)) {
            case 'true':
                $val = TRUE;
                break;
            case 'false':
                $val = FALSE;
                break;
            case 'null':
                $val = NULL;
                break;
            default:
                if (array_key_exists($prop, $this->varNodes))
                    $val = $this->varNodes[$prop];
                else
                    $val = $this->debugMessage("Undefined value - '$prop'");
        }
        return $val;
    }


    /* evaluate template expression, return node? */
    protected function evalExpression($expr) {
        $lexed = $this->expressionLexer($expr);
        set_error_handler(create_function('$code,$msg', 'throw new Exception($msg);'), E_ALL);
        try {
//echo '$'.$this->sandboxLabel.'=& $obj; return '.$lexed.';<br />';
            $fn = @create_function('&$obj', '$'.$this->sandboxLabel.'=& $obj; return '.$lexed.';');
            if ($fn)
                $result = $fn($this); // TODO: change $this to sandbox obj
            else
                $result = $this->debugMessage("Syntax error in {{ $expr }}");
        } catch (Exception $e) {
            $result = $this->debugMessage($e->getMessage());
        }
//echo $result . "<br />";
        restore_error_handler();
        /* if result is scalar value, build dummy_element with unique id */
        if (is_scalar($result)) {
            $element = $this->contentDom->createElement('dummy_element');
            $id_val = 'autogen_' . md5($result);
            $element->setAttribute('id', $id_val);
            /* load markup into node tree or append as text */
            $fragment = $this->contentDom->createDocumentFragment();
            if (@$fragment->appendXML($result)) {
                $element->appendChild($fragment);
            } else {
                $element->appendChild($this->dom->createTextNode($result));
            }
            $result = $element;
        }
        return $result;
    }

    /* take template expression, return sandboxed php expression */
    protected function expressionLexer($expr) {
        $callback = array($this, 'prependInterfaceRef');
        return $this->processNonstringChunks($expr, $callback);
    }
    /* prepends object reference onto method or property */
    protected function prependInterfaceRef($tpl_code) {
        $label_pat = '[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*';
        return preg_replace("/[$]?($label_pat(?:->$label_pat)*)/", '$'.$this->sandboxLabel.'->$1', $tpl_code);
    }
    /* process non-quoted chunks with callback function, return resulting string */
    protected function processNonstringChunks($statement, $callback) {
        $chunks = preg_split('/([\\\\"\'])/', $statement, -1, PREG_SPLIT_DELIM_CAPTURE);
        $chunks = array_filter($chunks); // remove empty items
        $statement = array();
        $string_delim = NULL;
        $escape_char = NULL;
        foreach ($chunks as $chunk) {
            if ($escape_char && $chunk) {
                $escape_char = FALSE;
            } else {
                switch ($chunk) {
                    case $string_delim:
                        $string_delim = NULL;
                        $chunk = '\''; // convert all to single quotes
                        break;
                    case '"':
                        if (!$string_delim) {
                            $string_delim = $chunk; // delim gets orig double quote
                            $chunk = '\'';          // chunk converted to single quote
                        }
                        break;
                    case '\'':
                        if ($string_delim == '"') // if we're converting,
                            $chunk = '\\\'';      // escape single quotes
                        else if ($string_delim === NULL)
                            $string_delim = $chunk;
                        break;
                    case '\\':
                        $escape_char = TRUE;
                        break;
                    default:
                        if ($string_delim === NULL)
                            $chunk = call_user_func($callback, $chunk);
                }
            }
            $statement[] = $chunk;
        }
        return implode('', $statement);
    }
    /* return templar processing instruction that follows given node */
    protected function getProcessingInstructions($dom) {
        $instructions = array();
        $node = $dom->documentElement;
        while ($node = self::followingNode($node)) {
            if ($node->nodeType==7 // type 7 is PROCESSING_INSTRUCTION
                    && $node->target==$this->processingTarget) {
                $instructions[] = $node;
            }
        }
        return $instructions;
    }
    /* get template variable name of given node */
    protected function getVariableName($node) {
        if ($node->hasAttribute('id')) {
            $id_val = $node->getAttribute('id');
            $label = $this->unmangle($id_val);
        //} else if (FALSE) { // TODO: replace true with built-in check
        } else if (in_array($node->tagName, $this->builtInVars)) {
            $label = $node->tagName;
        } else {
            $label = NULL;
            // TODO: Throw exception, instead?
        }
        return $label;
    }
    /* return debug message if running in debug mode */
    protected function debugMessage($message) {
        //return ($this->debugMode) ? 'DEBUG: '.htmlentities($message) : '';
        return ($this->debugMode) ? "DEBUG: $message" : ''; // pushed into text node (auto-escapes)
    }

    /******************************************************************
     *
     *****************************************************************/
    /* return reference to first node of specified tag (use for unique tags) */
    protected static function nodeByTagName(&$dom, $tag_name) {
        $node_list = $dom->getElementsByTagName($tag_name);
        if (!$node_list)
            $node_list = $dom->getElementsByTagName(strtoupper($tag_name));
        return $node_list->length ? $node_list->item(0) : NULL;
    }
    /* removes node from dom tree */
    protected static function removeNode($node) {
        return ($node->parentNode) ? $node->parentNode->removeChild($node) : NULL;
    }

}


/**********************************************************************
 * default controller (for stand-alone implementation)
 *********************************************************************/
if (count(get_included_files()) == 1) { // if not included elsewhere
    $request = $_SERVER['REQUEST_URI'];
    if ($request == $_SERVER['SCRIPT_NAME']) {
        // explain htaccess
        echo <<<MARKUP
<html>
    <head>
        <title>Welcome to TemplarPHP</title>
        <meta name="ROBOTS" content="NOINDEX, FOLLOW">
    </head>
    <body>
        <h1>It's Alive! (sort-of)</h1>
        <p>
            [Message details and instructions go here...]
        </p>
    </body>
</html>
MARKUP;
    } else {
        $templar_main = new TemplarPHP();
        $templar_main->loadRequest($request);
        $templar_main->buildResponse();
        $templar_main->sendResponse();
    }
}

// closing PHP-tag omitted to prevent injection of whitespace
